# Copyright (c) 2019 Red Hat, Inc. All rights reserved. This copyrighted
# material is made available to anyone wishing to use, modify, copy, or
# redistribute it subject to the terms and conditions of the GNU General Public
# License v.2 or later.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""Database schema."""

import os
import re

import yaml

from kpet.misc import format_exception_stack
from kpet.misc import get_type_name
from kpet.misc import type_get_name

# It's OK, pylint: disable=too-many-lines


class Invalid(Exception):
    """Invalid data exception."""


class Type:
    """
    Schema for specified data type.

    Most basic schema validating the data to be an instance of specified
    type and resolving to the same. The base class for all other schemas.
    """

    def __init__(self, type_):
        """
        Initialize a type schema.

        Args:
            type_:  The type the data should be instance of.
        """
        assert isinstance(type_, type)
        self.type = type_
        self.representing = False

    def __repr__(self):
        """Output a debug representation of the schema."""
        return get_type_name(self) + (
            f"({type_get_name(self.type)})"
            # Expect children to have the type in the name
            # pylint: disable=unidiomatic-typecheck
            if type(self) is Type else "()"
        )

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:   The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        if not isinstance(data, self.type):
            raise Invalid(
                f"Invalid type: {get_type_name(data)},"
                f" expecting {type_get_name(self.type)}"
            )
        return data

    def recognize(self):
        """
        Recognize the schema.

        Recognize the schema - return the schema that resolved data would
        have.

        Returns:
            The schema the resolved data would have.
        """
        return self

    def resolve(self, data):
        """
        Resolve (validate and massage) data according to the schema.

        Args:
            data:   The data to resolve.

        Returns:
            The resolved data. Will match the recognized schema.
        """
        self.validate(data)
        return data


class Any(Type):
    # pylint: disable=too-few-public-methods
    """Schema for any value."""

    def __init__(self):
        """Initialize the schema."""
        super().__init__(object)


# The schema for any value
ANY = Any()


class Choice(Type):
    """A schema matching a choice of other schemas."""

    def __init__(self, *args):
        """
        Initialize a choice schema.

        Args:
            args:   A list of schemas the data can match.
        """
        for arg in args:
            assert isinstance(arg, Type)
        super().__init__(object)
        self.schemas = args
        self.recognizing = None

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            representation += "(" + ", ".join(
                repr(schema) for schema in self.schemas
            ) + ")"
            self.representing = False
        return representation

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        exc_list = []
        # For each schema
        for schema in self.schemas:
            try:
                schema.validate(data)
                return data
            except Invalid as exc:
                exc_list.append(exc)
        raise Invalid("\nand\n".join(format_exception_stack(exc)
                                     for exc in exc_list))

    def recognize(self):
        """
        Recognize the schema.

        Returns:
            The schema the resolved data would have.
        """
        recognized = self.recognizing
        if recognized is None:
            recognized = Choice()
            self.recognizing = recognized
            recognized.schemas = tuple(
                schema.recognize() for schema in self.schemas
            )
            self.recognizing = None
        return recognized

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        exc_list = []
        for schema in self.schemas:
            try:
                return schema.resolve(data)
            except Invalid as exc:
                exc_list.append(exc)
        raise Invalid("\nand\n".join(format_exception_stack(exc)
                                     for exc in exc_list))


class Attraction(Type):
    """
    Attraction schema.

    An abstract schema describing an ordered list of schemas, optionally
    intermixed with data conversion functions of undefined purpose. Validates
    against one of the schemas, recognizes as the last schema in the list.
    Mnemonic: data is attracted to the ultimate schema.
    Cannot resolve data.
    """

    def __init__(self, *args):
        """
        Initialize an attraction schema.

        Args:
            args:   A list of schemas and data conversion functions.
                    Can be mixed in any order, except the first and the last
                    items must be schemas. Cannot be empty. Converter
                    functions must accept a data argument and return the
                    converted data.
        """
        assert args
        for arg in args:
            assert isinstance(arg, Type) or callable(arg)
        assert isinstance(args[0], Type)
        assert isinstance(args[-1], Type)
        super().__init__(object)
        self.schemas_and_converters = args

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            representation += "(" + ", ".join(
                repr(schema_or_converter)
                for schema_or_converter in self.schemas_and_converters
            ) + ")"
            self.representing = False
        return representation

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        exc_list = []
        # For each schema/converter
        for schema_or_converter in self.schemas_and_converters:
            # If it's a schema
            if isinstance(schema_or_converter, Type):
                try:
                    schema_or_converter.validate(data)
                    return data
                except Invalid as exc:
                    exc_list.append(exc)
        raise Invalid("\nand\n".join(format_exception_stack(exc)
                                     for exc in exc_list))

    def recognize(self):
        """
        Recognize the schema.

        Returns:
            The schema the resolved data would have.
        """
        return self.schemas_and_converters[-1].recognize()

    def resolve(self, data):
        """Resolve data according to the schema (Unimplemented)."""
        raise NotImplementedError()


class Succession(Attraction):
    """
    Succession schema.

    A schema describing a succession of accepted schema versions and the means
    to inherit the legacy data - converter functions. Validates against one of
    the schemas, recognizes as, and resolves to the last schema in the list.

    Each uninterrupted sequence of supplied converter functions must accept
    data validated by the preceding schema and return data validated by the
    following schema.
    """

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        # Last valid schema
        last_valid_schema = None
        # We find the first matching schema, then proceed converting and
        # validating until we get to the last schema.
        # For each schema/converter in the succession
        for schema_or_converter in self.schemas_and_converters:
            # If it's a schema
            if isinstance(schema_or_converter, Type):
                try:
                    # Validate the data
                    schema_or_converter.validate(data)
                    last_valid_schema = schema_or_converter
                except Invalid:
                    # Cannot fail validation after a matching schema is found
                    assert last_valid_schema is None
            # Else it's a conversion function, and if we found valid schema
            elif last_valid_schema:
                # Convert the data for the next schema/converter
                data = schema_or_converter(data)
        # We should arrive at the last schema
        assert last_valid_schema is self.schemas_and_converters[-1]
        # Resolve the data with the last schema
        return last_valid_schema.resolve(data)


class Reduction(Attraction):
    """
    Reduction schema.

    A schema describing a general schema and a choice of specific schemas for
    the same data, along with the means to convert the data from each of the
    specific schemas to the general one (converter functions). It essentially
    reduces a choice of schemas to one schema, hence the name. Validates
    against one of the schemas, recognizes as, and resolves to the last schema
    in the list (the general schema).

    Each uninterrupted sequence of supplied converter functions must accept
    data validated by the preceding (specific) schema and return data
    validated by the last schema in the list (the general schema).
    """

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        # First valid schema
        first_valid_schema = None
        # We find the first matching schema, then run any following converters
        # until the next schema.
        for schema_or_converter in self.schemas_and_converters:
            # If it's a schema
            if isinstance(schema_or_converter, Type):
                # If we found our schema (and converted data) already
                if first_valid_schema:
                    break
                # Try to validate the data
                try:
                    schema_or_converter.validate(data)
                    first_valid_schema = schema_or_converter
                except Invalid:
                    pass
            # Else it's a conversion function, and if we found valid schema
            elif first_valid_schema:
                # Convert the data for the next converter/last schema
                data = schema_or_converter(data)
        # We should've matched a schema, guaranteed by validation
        assert first_valid_schema is not None
        # Resolve the data with the last schema
        return self.schemas_and_converters[-1].resolve(data)


class Null(Type):
    """Null schema."""

    def __init__(self):
        """Initialize the schema."""
        super().__init__(type(None))


class Nothing(Type):
    """Schema matching nothing."""

    def __init__(self):
        """Initialize the schema."""
        class Unreachable:  # pylint: disable=too-few-public-methods
            """A dummy class, unreachable by anyone."""

        super().__init__(Unreachable)


# The schema for None (representation of JSON's null)
NULL = Null()

# The schema matching nothing
NOTHING = Nothing()

# A regular expression pattern matching anything
MATCH_ANYTHING_PATTERN = "(.|\\n)*"


class String(Type):
    """String schema."""

    def __init__(self, pattern=MATCH_ANYTHING_PATTERN):
        """
        Initialize a string schema.

        Args:
            pattern: Regular expression pattern the string must match as a
                     whole. Optional, the default matches anything.
        """
        super().__init__(str)
        assert isinstance(pattern, str)
        self.regex = re.compile(pattern)

    def __repr__(self):
        """Output a debug representation of the schema."""
        pattern = self.regex.pattern
        if pattern == MATCH_ANYTHING_PATTERN:
            pattern = ""
        else:
            pattern = repr(pattern)
        return f"{get_type_name(self)}({pattern})"

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        if not self.regex.fullmatch(data):
            raise Invalid(
                f"String \"{data}\" doesn't match "
                f"regular expression \"{self.regex.pattern}\""
            )
        return data


# The schema matching any string
STRING = String()


class Int(Type):
    """Integer number schema."""

    def __init__(self):
        """Initialize the schema."""
        super().__init__(int)


class Float(Type):
    """Floating-point number schema."""

    def __init__(self):
        """Initialize the schema."""
        super().__init__(float)


class Boolean(Type):
    """Boolean schema."""

    def __init__(self):
        """Initialize the schema."""
        super().__init__(bool)


class Regex(String):
    """Regular expression string schema."""

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        try:
            re.compile(data)
        except re.error as exc:
            raise Invalid("Invalid regular expression") from exc
        return data

    def recognize(self):
        """
        Recognize the schema.

        Returns:
            The schema the resolved data would have.
        """
        return Type(re.Pattern)

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        return re.compile(data)


# MULTIREGEX 👩‍🦰🪪
class MultiRegex(Reduction):
    """
    The schema for a regular expression string or a list thereof.

    Resolves to a list of compiled regular expressions.
    """

    def __init__(self):
        """Initialize a MultiRegex schema."""
        super().__init__(Regex(), lambda x: [x], List(Regex()))

    def __repr__(self):
        """Output a debug representation of the schema."""
        return f"{get_type_name(self)}()"


class RelativeFilePath(String):
    """
    Relative file path schema.

    Relative file path schema, resolved to the same schema and absolute file
    path.
    """

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        return os.path.abspath(data)


class YAMLFile(String):
    """
    YAML file path schema.

    YAML file path schema, resolved to the file contents according to
    specified schema.
    """

    def __init__(self, contents_schema):
        """Initialize the schema."""
        assert isinstance(contents_schema, Type)
        super().__init__()
        self.contents_schema = contents_schema

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            representation += f"({self.contents_schema!r})"
            self.representing = False
        return representation

    def recognize(self):
        """
        Recognize the schema.

        Returns:
            The schema the resolved data would have.
        """
        return self.contents_schema.recognize()

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        file_path = os.path.abspath(data)

        # Load the data
        with open(file_path, "r", encoding='utf8') as resolved_data_file:
            resolved_data = yaml.safe_load(resolved_data_file)

        # Resolve loaded data
        try:
            return self.contents_schema.resolve(resolved_data)
        except Invalid as exc:
            raise Invalid(f"Invalid contents of {file_path}") from exc


class ScopedYAMLFile(YAMLFile):
    """
    Scoped YAML file path schema.

    YAML file path schema, resolved to file contents according to specified
    schema, changes the current directory to the file's directory when
    resolving the contents.
    """

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        file_path = os.path.abspath(data)
        dir_path = os.path.dirname(file_path)

        # Load the data
        with open(file_path, "r", encoding='utf8') as resolved_data_file:
            resolved_data = yaml.safe_load(resolved_data_file)

        # Validate and resolve loaded data
        orig_dir_path = os.getcwd()
        os.chdir(dir_path)
        try:
            return self.contents_schema.resolve(resolved_data)
        except Invalid as exc:
            raise Invalid(f"Invalid contents of {file_path}") from exc
        finally:
            os.chdir(orig_dir_path)


class List(Type):
    """List schema, with every element matching a single specified schema."""

    def __init__(self, element_schema, min_len=0):
        """
        Initialize a List schema.

        Args:
            element_schema: An instance of the specific Type type the list
                            items should be instance of.
            min_len:        Optional parameter to force the list to contain at
                            least "min_len" elements. Defaults to 0.
        """
        assert isinstance(element_schema, Type)
        assert isinstance(min_len, int)
        assert min_len >= 0
        super().__init__(list)
        self.element_schema = element_schema
        self.min_len = min_len

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            min_len_arg = self.min_len
            if min_len_arg == 0:
                min_len_arg = ""
            else:
                min_len_arg = f", {min_len_arg}"
            representation += f"({self.element_schema!r}{min_len_arg})"
            self.representing = False
        return representation

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)

        if len(data) < self.min_len:
            raise Invalid(
                f"This list must have at least {self.min_len} elements, "
                f"but only has {len(data)}!"
            )

        for index, value in enumerate(data):
            try:
                self.element_schema.validate(value)
            except Invalid as exc:
                raise Invalid(f"Invalid value at index {index}") from exc

        return data

    def recognize(self):
        """Recognize the schema."""
        return List(self.element_schema.recognize())

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        return [self.element_schema.resolve(value) for value in data]


class Dict(Type):
    """Dictionary schema, with separate schemas for all keys/values."""

    def __init__(self, value_schema, key_schema=STRING):
        """
        Initialize a dictionary schema.

        Args:
            value_schema:   Schema for dictionary values.
            key_schema:     Schema for dictionary keys.
                            Optional. Default is STRING.
        """
        assert isinstance(key_schema, Type)
        assert isinstance(value_schema, Type)
        super().__init__(dict)
        self.key_schema = key_schema
        self.value_schema = value_schema

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            key_schema_arg = self.key_schema
            if key_schema_arg is STRING:
                key_schema_arg = ""
            else:
                key_schema_arg = f", {key_schema_arg!r}"
            representation += f"({self.value_schema!r}{key_schema_arg})"
            self.representing = False
        return representation

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        for key, value in data.items():
            try:
                self.key_schema.validate(key)
            except Invalid as exc:
                raise Invalid(f"Invalid key \"{key}\"") from exc
            try:
                self.value_schema.validate(value)
            except Invalid as exc:
                raise Invalid(f"Invalid value with key \"{key}\"") from exc
        return data

    def recognize(self):
        """Recognize the schema."""
        return Dict(self.value_schema.recognize())

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        resolved_data = {}
        for key, value in data.items():
            resolved_data[key] = self.value_schema.resolve(value)
        return resolved_data


class Struct(Dict):
    """
    Struct schema.

    Dictionary schema, with string keys and each key having values with its
    own schema.
    """

    def __init__(self, **attrs):
        """
        Initialize a struct schema.

        Args:
            attrs:  A dictionary of attribute keys and value definitions.
                    A value definition is a tuple containing the schema for
                    the attribute value, a default definition, and the
                    optional (absent or None) schema for the default value, if
                    different from the attribute schema and the default
                    definition is a callable.

                    The default definition is one of:
                    * False - for a required attribute.
                    * True - for an optional attribute without a default. The
                      attribute is not added if its key is missing from data.
                    * A callable - for an optional attribute with a default.
                      The callable is called to produce the default, if the
                      attribute key is missing from data. The produced default
                      must match either the recognized schema, or the default
                      schema, if specified.

                    Additionally the following translations are supported:
                    * schema => (schema, False)
                    * [schema] => (schema, type(None), NULL)
                    * {schema} => (schema, True)
        """
        assert isinstance(attrs, dict)
        assert all(
            isinstance(k, str) and (
                isinstance(v, tuple) and
                len(v) == 2 and
                isinstance(v[0], Type) and
                (isinstance(v[1], bool) or callable(v[1]))
                or
                isinstance(v, tuple) and
                len(v) == 3 and
                isinstance(v[0], Type) and (
                    callable(v[1]) and
                    isinstance(v[2], (type(None), Type))
                    or
                    isinstance(v[1], bool) and
                    v[2] is None
                )
                or
                isinstance(v, Type)
                or
                isinstance(v, list) and
                len(v) == 1 and
                isinstance(v[0], Type)
                or
                isinstance(v, set) and
                len(v) == 1 and
                isinstance(list(v)[0], Type)
            )
            for k, v in attrs.items()
        )
        super().__init__(ANY)
        # Normalize value definitions to 3-tuples
        self.attrs = {
            name: (
                (value_def, False, None)
                if isinstance(value_def, Type) else
                (value_def[0], type(None), NULL)
                if isinstance(value_def, list) else
                (list(value_def)[0], True, None)
                if isinstance(value_def, set) else
                value_def + (None, )
                if len(value_def) == 2 else
                value_def
            )
            for name, value_def in attrs.items()
        }

    def __repr__(self):
        """Output a debug representation of the schema."""
        representation = f"{get_type_name(self)}#{id(self)}"
        if not self.representing:
            self.representing = True
            representation += "(" + ", ".join(
                f"{name}={value_def!r}"
                for name, value_def in self.attrs.items()
            ) + ")"
            self.representing = False
        return representation

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:       The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        super().validate(data)
        for name, (value_schema, default, _) in self.attrs.items():
            if name in data:
                try:
                    value_schema.validate(data[name])
                except Invalid as exc:
                    raise Invalid(f"Member \"{name}\" is invalid") from exc
            elif not default:
                raise Invalid(f"Required member \"{name}\" is missing")
        extra_names = set(data) - set(self.attrs)
        if extra_names:
            raise Invalid(f"Unexpected members encountered: {extra_names!r}")
        return data

    def recognize(self):
        """Recognize the schema."""
        return Struct(**{
            name: (
                value_schema.recognize() if default_schema is None else
                Choice(value_schema, default_schema).recognize(),
                default is True
            )
            for name, (value_schema, default, default_schema)
            in self.attrs.items()
        })

    def resolve(self, data):
        """Resolve data according to the schema."""
        self.validate(data)
        return self.recognize().validate({
            name: value_schema.resolve(data[name])
            if name in data else default()
            for name, (value_schema, default, _) in self.attrs.items()
            if name in data or callable(default)
        })


class Object:   # pylint: disable=too-few-public-methods
    """An abstract object type (not a schema) for use with Class schema."""

    # The name of an instance of the object to use in error messages
    NAME = "object"

    # The schema of the object data, to be passed to the constructor
    SCHEMA = ANY

    def __init__(self, data):
        """
        Initialize.

        Args:
            data:   The object data validated and resolved with the schema.
        """
        self.SCHEMA.validate(data)

    def __repr__(self):
        """Print the string representation of the object."""
        return get_type_name(self) + repr(self.__dict__)


class StructObject(Object):   # pylint: disable=too-few-public-methods
    """A Struct-based object type (not a schema) for use with Class schema."""

    # The name of an instance of the object to use in error messages
    NAME = "struct-based object"

    # The schema of the object data, must recognize to a Struct
    SCHEMA = Struct()

    def __init__(self, data):
        """
        Initialize.

        Args:
            data:   The object data validated and resolved with the schema.
        """
        super().__init__(data)
        # Assign attributes
        for attr_name, attr_value in self.SCHEMA.resolve(data).items():
            setattr(self, attr_name, attr_value)


class Class(Any):
    """
    Class instance schema.

    Class instance schema, resolves to a class instance with (arbitrary) data
    as the creation argument.
    """

    def __init__(self, instance_type):
        """
        Initialize the schema.

        Args:
            instance_type:  The type to create resolved instances with.
                            Must be a subclass of Object.
        """
        assert isinstance(instance_type, type)
        assert isinstance(getattr(instance_type, "NAME", None), str)
        assert isinstance(getattr(instance_type, "SCHEMA", None), Type)
        super().__init__()
        self.instance_type = instance_type

    def __repr__(self):
        """Output a debug representation of the schema."""
        return f"{get_type_name(self)}({type_get_name(self.instance_type)})"

    def validate(self, data):
        """
        Validate data according to the schema.

        Args:
            data:   The data to validate.

        Returns:
            The validated data.

        Raises:
            Invalid:    The data didn't match the schema.
        """
        try:
            return self.instance_type.SCHEMA.validate(data)
        except Invalid as exc:
            raise Invalid(f"Invalid {self.instance_type.NAME}") from exc

    def recognize(self):
        """Recognize the schema."""
        return Type(self.instance_type)

    def resolve(self, data):
        """Resolve data according to the schema."""
        try:
            return self.instance_type(data)
        except Invalid as exc:
            raise Invalid(f"Invalid {self.instance_type.NAME}") from exc
